以下是基于您提供的文章内容整理的 V2版本:高并发购票锁优化方案 的 Markdown 文档。
业务讲解:如何优化锁机制应对高并发购票 (V2版本)
1. 背景与问题 (V1版本的不足)
在 V1 版本中,我们使用了分布式锁来保证数据的一致性。 V1 锁策略:
java
@ServiceLock(name = PROGRAM_ORDER_CREATE_V1, keys = {"#programOrderCreateDto.programId"})存在问题(锁粒度过大): 锁的粒度是整个节目(programId)。
- 用户1购买“一等票”,用户2购买“二等票”。
- 虽然票档库存是分开管理的,但在 V1 策略下,用户2必须等待用户1释放锁。
- 结论:这导致并发性能较差,同一时间内该节目只能有一个用户进行购票操作。
2. 优化方案一:细化锁粒度
2.1 优化策略
将锁的粒度从“节目”缩小到“节目ID + 票档ID”。
- 原理:请求1(买一等票)和 请求2(买二等票)互不干扰,可以并发执行。
- 效果:系统的处理效率理论上可提高数倍(取决于票档数量)。
2.2 应对复杂场景(多票档购买)
如果用户同时购买多种类型的票(如同时买一等票和二等票),会面临锁重合问题。
- 解决方案:根据要购买的票档类型,依次获取多把锁。
- 防止死锁:
- 排序:对要获取的锁进行排序(如按票档ID升序),保证所有线程获取锁的顺序一致。
- 安全解锁:无论业务执行成功或异常,必须保证已获取的锁能被安全释放。
3. 优化方案二:引入本地锁 (多级锁架构)
3.1 缓解 Redis 压力
细化粒度后,短时间内会有大量请求竞争分布式锁,对 Redis 造成巨大压力(网络I/O开销大)。
解决方案:本地锁 + 分布式锁
- 本地锁(Local Lock):先竞争当前服务实例的本地锁(内存操作,速度快)。
- 分布式锁(Distributed Lock):获得本地锁的请求,才有资格去竞争分布式锁。
效果:相当于一个漏斗,先在 JVM 层面拦截掉大部分竞争,只有少部分请求会打到 Redis,显著降低 Redis 压力。
3.2 本地锁的内存管理 (Caffeine)
问题:如果使用 ConcurrentHashMap 存储本地锁,随着节目和票档增多,锁对象(ReentrantLock)会无限增加,导致 OOM(内存溢出)。
解决方案:使用 Caffeine 缓存。
- 原因:Caffeine 是基于 Java 8 的高性能缓存(Spring 5 默认缓存实现),支持 过期策略。
- 机制:设置过期时间(如2小时)。当某个“节目+票档”长时间无访问时,自动回收锁对象。
LocalLockCache 代码实现:
java
public class LocalLockCache {
// 本地锁缓存
private Cache<String, ReentrantLock> localLockCache;
// 本地锁过期时间(小时)
@Value("${durationTime:2}")
private Integer durationTime;
@PostConstruct
public void localLockCacheInit(){
localLockCache = Caffeine.newBuilder()
.expireAfterWrite(durationTime, TimeUnit.HOURS)
.build();
}
// 获得锁,Caffeine的get是线程安全的
public ReentrantLock getLock(String lockKey, boolean fair){
return localLockCache.get(lockKey, key -> new ReentrantLock(fair));
}
}4. 锁的类型选择 (公平 vs 非公平)
本项目支持两种锁模式,可根据业务需求切换。
| 特性 | 公平锁 (Fair) | 非公平锁 (Unfair) |
|---|---|---|
| 定义 | 严格遵循 FIFO (先进先出),先来先得。 | 允许插队,尝试获取锁时若锁可用则直接占用,失败才入队。 |
| 优点 | 避免线程饥饿,保证公平性。 | 性能更高,减少线程上下文切换开销。 |
| 缺点 | 性能较低,维护等待队列开销大。 | 可能导致某些线程长时间等待 (饥饿)。 |
| 适用场景 | 追求极致的用户体验 (先点先得)。 | 追求高吞吐量与性能 (推荐)。 |
关于混合锁的公平性局限: 即使本地锁和分布式锁都设置为“公平锁”,也只能保证局部有序,无法保证全局有序。 原因:请求1和请求4分别在不同机器先拿到本地锁,但请求4可能因为网络延迟低,比请求1先拿到分布式锁。
5. V2版本核心代码实现
5.1 控制层入口
java
@ApiOperation(value = "购票V2")
@PostMapping(value = "/create/v2")
public ApiResponse<String> createV2(@Valid @RequestBody ProgramOrderCreateDto programOrderCreateDto) {
return ApiResponse.ok(programOrderLock.createV2(programOrderCreateDto));
}5.2 核心加锁逻辑 (ProgramOrderLock)
流程总结:
- 参数验证:前置检查业务参数。
- 排序防死锁:对票档ID进行排序。
- 循环加锁:依次获取 本地锁 -> 分布式锁。
- 执行业务:所有锁获取成功后,执行下单逻辑。
- 逆序解锁:无论成功失败,按加锁相反顺序释放锁。
java
/**
* 订单优化版本v2
* 策略:本地锁 -> 分布式锁
* 粒度:节目id + 票档id
*/
@RepeatExecuteLimit(
name = RepeatExecuteLimitConstants.CREATE_PROGRAM_ORDER,
keys = {"#programOrderCreateDto.userId", "#programOrderCreateDto.programId"}
)
public String createV2(ProgramOrderCreateDto programOrderCreateDto) {
// 1. 业务参数验证 (前置)
compositeContainer.execute(CompositeCheckType.PROGRAM_ORDER_CREATE_CHECK.getValue(), programOrderCreateDto);
List<SeatDto> seatDtoList = programOrderCreateDto.getSeatDtoList();
List<Long> ticketCategoryIdList = new ArrayList<>();
// 2. 统计并排序票档ID (避免死锁关键步骤)
if (CollectionUtil.isNotEmpty(seatDtoList)) {
ticketCategoryIdList = seatDtoList.stream()
.map(SeatDto::getTicketCategoryId)
.distinct()
.sorted() // 排序
.collect(Collectors.toList());
} else {
ticketCategoryIdList.add(programOrderCreateDto.getTicketCategoryId());
}
// 初始化锁集合
List<ReentrantLock> localLockList = new ArrayList<>(ticketCategoryIdList.size());
List<RLock> serviceLockList = new ArrayList<>(ticketCategoryIdList.size());
List<ReentrantLock> localLockSuccessList = new ArrayList<>(ticketCategoryIdList.size());
List<RLock> serviceLockSuccessList = new ArrayList<>(ticketCategoryIdList.size());
try {
// 3. 加锁流程
for (Long ticketCategoryId : ticketCategoryIdList) {
String lockKey = StrUtil.join("-", PROGRAM_ORDER_CREATE_V2,
programOrderCreateDto.getProgramId(), ticketCategoryId);
// 3.1 获取并尝试加本地锁 (非公平锁)
ReentrantLock localLock = localLockCache.getLock(lockKey, false);
// ... (省略加锁tryLock逻辑,成功则加入localLockSuccessList)
// 3.2 获取并尝试加分布式锁 (非公平锁)
RLock serviceLock = serviceLockTool.getLock(LockType.Reentrant, lockKey);
// ... (省略加锁tryLock逻辑,成功则加入serviceLockSuccessList)
}
// 4. 执行下单业务
return programOrderService.create(programOrderCreateDto);
} finally {
// 5. 解锁流程 (逆序释放)
// 释放分布式锁
for (int i = serviceLockSuccessList.size() - 1; i >= 0; i--) {
try {
serviceLockSuccessList.get(i).unlock();
} catch (Throwable t) {
// log error
}
}
// 释放本地锁
for (int i = localLockSuccessList.size() - 1; i >= 0; i--) {
try {
localLockSuccessList.get(i).unlock();
} catch (Throwable t) {
// log error
}
}
}
}6. 总结与展望
V2 版本优化点
- 锁粒度细化:从“节目”级别降级为“节目+票档”级别,大幅提升并发度。
- 多级锁架构:引入本地锁作为前置屏障,显著减少 Redis 访问压力。
- 防内存溢出:利用 Caffeine 管理本地锁生命周期。
遗留问题与后续方向
虽然 V2 版本解决了大部分并发问题,但在生成订单逻辑中,依然需要在持有锁的情况下进行数据库/Redis交互(查询余票、扣减库存、锁定座位),这依然是串行操作。
终极杀招 (V3版本预告): 无锁化! 将余票验证、座位验证、扣减库存、锁定座位等操作封装在 Lua 脚本 中一次性执行。如果脚本执行成功,则直接生成订单,从而彻底移除应用层的分布式锁,实现极致性能。